// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Collections.Generic;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Text;
using Microsoft.Win32.SafeHandles;

namespace System.IO
{
    /* This class is used by for reading from the stdin when it is a terminal.
     * It is designed to read stdin in raw mode for interpreting
     * key press events and maintain its own buffer for the same.
     * which is then used for all the Read operations
     */
    internal sealed class StdInReader : TextReader
    {
        private static string? s_moveLeftString; // string written to move the cursor to the left
        private static string? s_clearToEol;     // string written to clear from cursor to end of line

        private readonly StringBuilder _readLineSB; // SB that holds readLine output.  This is a field simply to enable reuse; it's only used in ReadLine.
        private readonly Stack<ConsoleKeyInfo> _tmpKeys = new Stack<ConsoleKeyInfo>(); // temporary working stack; should be empty outside of ReadLine
        private readonly Stack<ConsoleKeyInfo> _availableKeys = new Stack<ConsoleKeyInfo>(); // a queue of already processed key infos available for reading
        private readonly Decoder _decoder;
        private readonly Encoding _encoding;
        private readonly Encoder _echoEncoder;
        private Encoder? _bufferReadEncoder;

        private char[] _unprocessedBufferToBeRead; // Buffer that might have already been read from stdin but not yet processed.
        private const int BytesToBeRead = 1024; // No. of bytes to be read from the stream at a time.
        private int _startIndex; // First unprocessed index in the buffer;
        private int _endIndex; // Index after last unprocessed index in the buffer;

        internal StdInReader(Encoding encoding)
        {
            Debug.Assert(!Console.IsInputRedirected); // stdin is a terminal.

            _encoding = encoding;
            _unprocessedBufferToBeRead = new char[encoding.GetMaxCharCount(BytesToBeRead)];
            _startIndex = 0;
            _endIndex = 0;
            _readLineSB = new StringBuilder();
            _echoEncoder = _encoding.GetEncoder();
            _decoder = _encoding.GetDecoder();
        }

        /// <summary> Checks whether the unprocessed buffer is empty. </summary>
        internal bool IsUnprocessedBufferEmpty()
        {
            return _startIndex >= _endIndex; // Everything has been processed;
        }

        internal void AppendExtraBuffer(ReadOnlySpan<byte> buffer)
        {
            // Most inputs to this will have a buffer length of one.
            // The cases where it is larger than one only occur in ReadKey
            // when the input is not redirected, so those cases should be
            // rare, so just allocate.
            const int MaxStackAllocation = 256;
            int maxCharsCount = _encoding.GetMaxCharCount(buffer.Length);
            Span<char> chars = (uint)maxCharsCount <= MaxStackAllocation ?
                stackalloc char[MaxStackAllocation] :
                new char[maxCharsCount];
            int charLen = _decoder.GetChars(buffer, chars, flush: false);
            chars = chars.Slice(0, charLen);

            // Ensure our buffer is large enough to hold all of the data
            if (IsUnprocessedBufferEmpty())
            {
                _startIndex = _endIndex = 0;
            }
            else
            {
                Debug.Assert(_endIndex > 0);
                int spaceRemaining = _unprocessedBufferToBeRead.Length - _endIndex;
                if (spaceRemaining < chars.Length)
                {
                    Array.Resize(ref _unprocessedBufferToBeRead, _unprocessedBufferToBeRead.Length * 2);
                }
            }

            // Copy the data into our buffer
            chars.CopyTo(_unprocessedBufferToBeRead.AsSpan(_endIndex));
            _endIndex += charLen;
        }

        internal static unsafe int ReadStdin(byte* buffer, int bufferSize)
        {
            int result = Interop.CheckIo(Interop.Sys.ReadStdin(buffer, bufferSize));
            Debug.Assert(result >= 0 && result <= bufferSize); // may be 0 if hits EOL
            return result;
        }

        public override string? ReadLine()
        {
            bool isEnter = ReadLineCore(consumeKeys: true);
            string? line = null;
            if (isEnter || _readLineSB.Length > 0)
            {
                line = _readLineSB.ToString();
                _readLineSB.Clear();
            }
            return line;
        }

        public int ReadLine(Span<byte> buffer)
        {
            if (buffer.IsEmpty)
            {
                return 0;
            }

            // Don't read a new line if there are remaining characters in the StringBuilder.
            if (_readLineSB.Length == 0)
            {
                bool isEnter = ReadLineCore(consumeKeys: true);
                if (isEnter)
                {
                    _readLineSB.Append('\n');
                }
            }

            // Encode line into buffer.
            Encoder encoder = _bufferReadEncoder ??= _encoding.GetEncoder();
            int bytesUsedTotal = 0;
            int charsUsedTotal = 0;
            foreach (ReadOnlyMemory<char> chunk in _readLineSB.GetChunks())
            {
                Debug.Assert(!buffer.IsEmpty);

                encoder.Convert(chunk.Span, buffer, flush: false, out int charsUsed, out int bytesUsed, out bool completed);
                buffer = buffer.Slice(bytesUsed);
                bytesUsedTotal += bytesUsed;
                charsUsedTotal += charsUsed;

                if (!completed || buffer.IsEmpty)
                {
                    break;
                }
            }
            _readLineSB.Remove(0, charsUsedTotal);
            return bytesUsedTotal;
        }

        // Reads a line in _readLineSB when consumeKeys is true,
        //              or _availableKeys when consumeKeys is false.
        // Returns whether the line was terminated using the Enter key.
        private bool ReadLineCore(bool consumeKeys)
        {
            Debug.Assert(_tmpKeys.Count == 0);
            Debug.Assert(consumeKeys || _availableKeys.Count == 0);

            // _availableKeys either contains a line that was already read,
            // or we need to read a new line from stdin.
            bool freshKeys = _availableKeys.Count == 0;

            // Don't carry over chars from previous ReadLine call.
            _readLineSB.Clear();

            Interop.Sys.InitializeConsoleBeforeRead();
            try
            {
                // Read key-by-key until we've read a line.
                while (true)
                {
                    ConsoleKeyInfo keyInfo = freshKeys ? ReadKey() : _availableKeys.Pop();
                    if (!consumeKeys && keyInfo.Key != ConsoleKey.Backspace) // backspace is the only character not written out in the below if/elses.
                    {
                        _tmpKeys.Push(keyInfo);
                    }

                    // Handle the next key.  Since for other functions we may have ended up reading some of the user's
                    // input, we need to be able to handle manually processing that input, and so we do that processing
                    // for all input.  As such, we need to special-case a few characters, e.g. recognizing when Enter is
                    // pressed to end a line.  We also need to handle Backspace specially, to fix up both our buffer of
                    // characters and the position of the cursor.  More advanced processing would be possible, but we
                    // try to keep this very simple, at least for now.
                    if (keyInfo.Key == ConsoleKey.Enter)
                    {
                        if (freshKeys)
                        {
                            EchoToTerminal('\n');
                        }
                        return true;
                    }
                    else if (IsEol(keyInfo.KeyChar))
                    {
                        return false;
                    }
                    else if (keyInfo.Key == ConsoleKey.Backspace)
                    {
                        bool removed = false;
                        if (consumeKeys)
                        {
                            int len = _readLineSB.Length;
                            if (len > 0)
                            {
                                _readLineSB.Length = len - 1;
                                removed = true;
                            }
                        }
                        else
                        {
                            removed = _tmpKeys.TryPop(out _);
                        }

                        if (removed && freshKeys)
                        {
                            // The ReadLine input may wrap across terminal rows and we need to handle that.
                            // note: ConsolePal will cache the cursor position to avoid making many slow cursor position fetch operations.
                            if (ConsolePal.TryGetCursorPosition(out int left, out int top, reinitializeForRead: true) &&
                                left == 0 && top > 0)
                            {
                                s_clearToEol ??= ConsolePal.TerminalFormatStringsInstance.ClrEol ?? string.Empty;

                                // Move to end of previous line
                                ConsolePal.SetTerminalCursorPosition(ConsolePal.WindowWidth - 1, top - 1);
                                // Clear from cursor to end of the line
                                ConsolePal.WriteTerminalAnsiString(s_clearToEol, mayChangeCursorPosition: false);
                            }
                            else
                            {
                                if (s_moveLeftString == null)
                                {
                                    string? moveLeft = ConsolePal.TerminalFormatStringsInstance.CursorLeft;
                                    s_moveLeftString = !string.IsNullOrEmpty(moveLeft) ? moveLeft + " " + moveLeft : string.Empty;
                                }

                                ConsolePal.WriteTerminalAnsiString(s_moveLeftString);
                            }
                        }
                    }
                    else if (keyInfo.Key == ConsoleKey.Tab)
                    {
                        if (consumeKeys)
                        {
                            _readLineSB.Append(keyInfo.KeyChar);
                        }
                        if (freshKeys)
                        {
                            EchoToTerminal(' ');
                        }
                    }
                    else if (keyInfo.Key == ConsoleKey.Clear)
                    {
                        _readLineSB.Clear();
                        if (freshKeys)
                        {
                            ConsolePal.WriteTerminalAnsiString(ConsolePal.TerminalFormatStringsInstance.Clear);
                        }
                    }
                    else if (keyInfo.KeyChar != '\0')
                    {
                        if (consumeKeys)
                        {
                            _readLineSB.Append(keyInfo.KeyChar);
                        }
                        if (freshKeys)
                        {
                            EchoToTerminal(keyInfo.KeyChar);
                        }
                    }
                }
            }
            finally
            {
                Interop.Sys.UninitializeConsoleAfterRead();

                // If we're not consuming the read input, make the keys available for a future read
                while (_tmpKeys.Count > 0)
                {
                    _availableKeys.Push(_tmpKeys.Pop());
                }
            }
        }

        public override int Read() => ReadOrPeek(peek: false);

        public override int Peek() => ReadOrPeek(peek: true);

        private int ReadOrPeek(bool peek)
        {
            // If there aren't any keys in our processed keys stack, read a line to populate it.
            if (_availableKeys.Count == 0)
            {
                ReadLineCore(consumeKeys: false);
            }

            // Now if there are keys, use the first.
            if (_availableKeys.Count > 0)
            {
                ConsoleKeyInfo keyInfo = peek ? _availableKeys.Peek() : _availableKeys.Pop();
                if (!IsEol(keyInfo.KeyChar))
                {
                    return keyInfo.KeyChar;
                }
            }

            // EOL
            return -1;
        }

        private static bool IsEol(char c)
        {
            return
                c != ConsolePal.s_posixDisableValue &&
                (c == ConsolePal.s_veolCharacter || c == ConsolePal.s_veol2Character || c == ConsolePal.s_veofCharacter);
        }

        /// <summary>
        /// Try to intercept the key pressed.
        ///
        /// Unlike Windows, Unix has no concept of virtual key codes.
        /// Hence, in case we do not recognize a key, we can't really
        /// get the ConsoleKey key code associated with it.
        /// As a result, we try to recognize the key, and if that does
        /// not work, we simply return the char associated with that
        /// key with ConsoleKey set to default value.
        /// </summary>
        public ConsoleKeyInfo ReadKey(bool intercept)
        {
            if (_availableKeys.Count > 0)
            {
                return _availableKeys.Pop();
            }

            ConsoleKeyInfo keyInfo = ReadKey();

            if (!intercept && keyInfo.KeyChar != '\0')
            {
                EchoToTerminal(keyInfo.KeyChar);
            }

            return keyInfo;
        }

        private unsafe ConsoleKeyInfo ReadKey()
        {
            Debug.Assert(_availableKeys.Count == 0);

            Interop.Sys.InitializeConsoleBeforeRead();
            try
            {
                if (IsUnprocessedBufferEmpty())
                {
                    // Read in bytes
                    byte* bufPtr = stackalloc byte[BytesToBeRead];
                    int result = ReadStdin(bufPtr, BytesToBeRead);
                    if (result > 0)
                    {
                        // Append them
                        AppendExtraBuffer(new ReadOnlySpan<byte>(bufPtr, result));
                    }
                    else
                    {
                        // Could be empty if EOL entered on its own.  Pick one of the EOL characters we have,
                        // or just use 0 if none are available.
                        return new ConsoleKeyInfo((char)
                            (ConsolePal.s_veolCharacter != ConsolePal.s_posixDisableValue ? ConsolePal.s_veolCharacter :
                             ConsolePal.s_veol2Character != ConsolePal.s_posixDisableValue ? ConsolePal.s_veol2Character :
                             ConsolePal.s_veofCharacter != ConsolePal.s_posixDisableValue ? ConsolePal.s_veofCharacter :
                             0),
                            default(ConsoleKey), false, false, false);
                    }
                }

                return KeyParser.Parse(_unprocessedBufferToBeRead, ConsolePal.TerminalFormatStringsInstance, ConsolePal.s_posixDisableValue, ConsolePal.s_veraseCharacter, ref _startIndex, _endIndex);
            }
            finally
            {
                Interop.Sys.UninitializeConsoleAfterRead();
            }
        }

        /// <summary>Gets whether there's input waiting on stdin.</summary>
        internal static bool StdinReady => Interop.Sys.StdinReady();

        private void EchoToTerminal(char c)
        {
            Span<byte> bytes = stackalloc byte[32]; // 32 bytes seems ample
            int bytesWritten = 1;
            if (Ascii.IsValid(c))
            {
                bytes[0] = (byte)c;
            }
            else
            {
                var chars = new ReadOnlySpan<char>(in c);
                bytesWritten = _echoEncoder.GetBytes(chars, bytes, flush: false);
                if (bytesWritten == 0)
                {
                    return;
                }
            }

            ConsolePal.WriteToTerminal(bytes.Slice(0, bytesWritten));
        }
    }
}
